Making a blogging system with Phoenix and React [Part 3]
making-a-blogging-system-with-phoenix-and-react-[part-3]Feature images
Welcome to this third part.
In the last post, we scaffolded our blog posts and made sure that Milkdown integrated with the Phoenix form.
Today, we're going to take care of our feature image. To do this, we need two hex packages. Add the following to your mix.exs
:
#mix.exs
{
#... your other packages,
{:waffle, "~> 1.1"},
{:waffle_ecto, "~> 0.0"},
{:ex_aws, "~> 2.1.2"}, # Skip if using local storage
{:ex_aws_s3, "~> 2.0"},# Skip if using local storage
{:hackney, "~> 1.9"}, # Skip if using local storage
{:sweet_xml, "~> 0.6"},# Skip if using local storage
}
then run the usual mix deps.get
.
I will be uploading images to my DigitalOcean storage space but if you're following along, feel free to use your local storage.
What is Waffle ?
Waffle, a library, exposes useful helpers that will allow us to rename, verify, crop, transform, version and save our feature image.
In my use case, I shrink the image size way down for the thumbnail version but leave the original untouched.
Waffle Ecto lets us save the reference to our images right in our form instead of handling it ourselves.
Let's get started:
Preparing file uploads
Waffle and Waffle Ecto need a little setup in order to be functional.
You can read most of it in their respective documentation :
Setting up Waffle
The config
The first thing to do is setting up the config.
If you are going to use local storage, you only need this part :
config :waffle,
storage: Waffle.Storage.Local,
asset_host: {:system, "ASSET_HOST"}
If, like me, you are going to use a file hosting service like DigitalOcean, then you'll have a few extra steps to follow .
To create the config, we'll need to retrieve your DigitalOcean's Space :
private and public keys
bucket name
host ("https://{bucket_name}.{region}.digitaloceanspaces.com")
It might look like this:
DO_SPACES_PUBLIC_KEY=my_public_key
DO_SPACES_SECRET_KEY=my_secret_key
AWS_S3_BUCKET= my-bucket
ASSET_HOST=https://my-bucket.syd1.digitaloceanspaces.com
EX_AWS_HOST=my-bucket.syd1.digitaloceanspaces.com
EX_AWS_REGION="us-east-1"
Then in our config.ex
:
# Configures file uploads to DigitalOcean
config :waffle,
storage: Waffle.Storage.S3,
bucket: {:system, "AWS_S3_BUCKET"},
asset_host: {:system, "ASSET_HOST"}
config :ex_aws,
debug_requests: true,
json_codec: Jason,
access_key_id: {:system, "DO_SPACES_PUBLIC_KEY"},
secret_access_key: {:system, "DO_SPACES_SECRET_KEY"}
config :ex_aws, :s3,
scheme: "https://",
host: {:system, "EX_AWS_HOST"},
region: {:system, "EX_AWS_REGION"}
Why does it say "AWS" if we're using Digital Ocean ?
Because I used the given config and did not feel like changing them.
Because Digital Ocean's API is close to AWS' own convention. See here : https://docs.digitalocean.com/reference/api/spaces-api/#aws-s3-compatibility
The
:s3
variableEX_AWS_REGION
needs to be set to an AWS region ("us-east-1"). This will only be used when creating a new bucket, for verification purposes. Any other API call will go to theEX_AWS_HOST
we specified.
Scaffolding the file
Now that our base config is done, it is time to create our Waffle files. This can be done by using the command mix waffle.g feature_image
.
In our lib/app_web/
folder we'll find a new folder called uploaders
inside which lives feature_image.ex
file. This is where all the upload transformation, naming etc will happen.
Let's open it and add some modifications.
defmodule App.FeatureImage do
use Waffle.Definition
# Include ecto support (requires package waffle_ecto installed):
+ use Waffle.Ecto.Definition
+ @versions [:original, :thumb]
+ @acl :public_read
# ... Things I would care about if this wasn't self hosted
end
What's happening here ?
We're letting Waffle know that it should work in conjunction with
Waffle.Ecto
.We specify the two versions we want to create for each uploaded file
We set the default read permissions to public on the files that get uploaded
Let's keep configuring our uploads :
defmodule App.FeatureImage do
#[...]
# Define a thumbnail transformation:
+ def transform(:thumb, _) do
+ {:convert, "-strip -thumbnail 100x100^ -gravity center -extent 100x100"}
+ end
+ def transform(:original, _) do
+ {:convert, "-trim -resize 600x400 -gravity center -extent 600x400"}
+ end
+ # Override the persisted filenames:
+ def filename(version, {file, scope}) do
+ file_name = Path.basename(file.file_name, Path.extname(file.file_name))
+ "#{scope.id}_#{version}_#{file_name}"
+ end
# If you're using local storage this one is for you :
# Override the storage directory:
# def storage_dir(version, {file, scope}) do
# "uploads/user/avatars/#{scope.id}"
# end
# Provide a default URL if there hasn't been a file uploaded
+ def default_url(_version, _scope) do
+ "https://my-bucket.region.cdn.digitaloceanspaces.com/uploads/no_image_found.png"
+ end
#[more code here]
# Only for online hosting:
+ def s3_object_headers(_version, {file, _scope}) do
+ [content_type: MIME.from_path(file.file_name)]
+ end
In this snippet we :
define the shapes and sizes of our different image uploads with
transform/2
, which usesimagemagick
under the hoodgive the file a name based on the resource created by Ecto (our scope)
set a default image in case none was uploaded for our post
infer the MIMEtype of our content thanks to
MIME.from_path/1
Awesome ! Now that this is all sorted though, we need to let Ecto know what's happening and how to deal with it.
Modifying the schema and changeset
Let's head to our /lib/app/blog/post.ex
file and change some things.
defmodule App.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
+ use Waffle.Ecto.Schema
schema "posts" do
field :title, :string
field :content, :string
field :published_at, :naive_datetime
field :tags, :string
- field :feature_image, :string
+ field :feature_image, Ownidevapi.FeatureImage.Type
timestamps(type: :utc_datetime)
end
@doc false
def changeset(post, attrs) do
post
- |> cast(attrs, [:title, :content, :published_at,:tags, :feature_image])
+ |> cast(attrs, [:title, :content, :published_at,:tags])
- |> validate_required([:title, :content, :published_at, :tags, :feature_image])
+ |> validate_required([:title, :content, :published_at, :tags])
end
+ def feature_image_changeset(post, attrs) do
+ post
+ |> cast_attachments(attrs, [:feature_image], allow_paths: true)
+ |> validate_required([:feature_image])
+ end
end
Here we use a new type of schema, provided by Waffle Ecto, that will allow us to store the reference to our uploaded file.
Since we want to reference our images with the ID of the post, we have to modify our changesets. changeset/2
will handle the initial insert of the post, hence why we remove :feature_image
as a required field and why we don't bother casting it (it would fail anyway). feature_image_changeset/2
will be the one who kickstarts the upload process with cast_attachments/4
. Since we're temporary saving the file on our server, we need to make sure to pass the allow_path
option.
Changing our insert method
Since we added that changeset, we need to use it somewhere.
Globally, what we want is to :
Insert our new post (without the image) into our database
Get this new post's id
Upload the image to our service, named using the post id
Update our post to contain the reference to that image in the service
Ecto offers a great way to do this with Ecto.Multi
. Let's head over to our lib/app/blog.ex
,find the create_post/1
method and completely modify it as follows :
def create_post(attrs \\ %{}) do
Ecto.Multi.new() # Start a new transaction
|> Ecto.Multi.insert(:post, Post.changeset(%Post{}, attrs)) # Insert our post using our normal changeset
|> Ecto.Multi.update(:post_with_image, &Post.feature_image_changeset(&1.post, attrs)) # Grab the newly created post and pass it through our new changeset with the same attrs (containing the path of our image)
|> Repo.transaction() # Run the transaction
|> case do
{:ok, %{post_with_image: post}} -> {:ok, post}
{:error, _, changeset, _} -> {:error, changeset}
end
end
Notice how we really only had to do part 1 ourselves and how our changeset and the cast_attachments/3
took care of everything else.
We can go ahead and modify our update_post/2
method, though since the post exist we can just pipe through our changesets:
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Post.feature_image_changeset(attrs)
|> Repo.update()
end
Enabling image uploads on the form
The next big part of this is to enable us to upload that file through the post form.
The form input
Phoenix LiveView gives us a component called live_file_input
that will allow us to consume the file into our saving process.
We'll open our lib/app_web/live/post_live/form_component.ex
where most of this is being done.
Let's replace the basic text input by the component, as well as a few helpers. I'm not reinventing the wheel here, for the purpose of this guide this can all be found in the Phoenix Documentation1
defmodule AppWeb.PostLive.FormComponent do
use AppWeb, :live_component
alias App.Blog
@impl true
def render(assigns) do
~H"""
<div>
<.header>
<%= @title %>
<:subtitle>Use this form to manage post records in your database.</:subtitle>
</.header>
<.simple_form
for={@form}
id="post-form"
phx-target={@myself}
phx-change="validate"
phx-submit="saved"
>
<.input field={@form[:title]} type="text" label="Title" />
<.input field={@form[:content]} type="textarea" label="Content" />
<.input field={@form[:published_at]} type="datetime-local" label="Published at" />
<.input field={@form[:tags]} type="text" label="Tags" />
- <.input field={@form[:feature_image]} type="text" label="Feature Image"/>
+ <div>
+ <.live_file_input upload={@uploads.feature_image} />
+ <section phx-drop-target={@uploads.feature_image.ref}>
+ <%= for entry <- @uploads.feature_image.entries do %>
+ <article class="upload-entry">
+ <figure>
+ <.live_img_preview entry={entry} />
+ <figcaption><%= entry.client_name %></figcaption>
+ </figure>
+ <progress value={entry.progress} max="100"> <%= entry.progress %>% </progress>
+ <button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel">×</button>
+ <%= for err <- upload_errors(@uploads.feature_image, entry) do %>
+ <p class="alert alert-danger"><%= error_to_string(err) %></p>
+ <% end %>
+ </article>
+ <% end %>
+ <%= for err <- upload_errors(@uploads.feature_image) do %>
+ <p class="alert alert-danger"><%= error_to_string(err) %></p>
+ <% end %>
+ </section>
+ </div>
<:actions>
<.button phx-disable-with="Saving...">Save Post</.button>
</:actions>
</.simple_form>
<div>
"""
# ... The mount functions etc
end
Simply enough, we add a file input that will allow the user (us in this case) to upload a file. We loop over the entries (even though there is only 1), display the image, its upload progress as well as whatever errors may arise.
That's it for the DOM, now to make it functional.
The functions
The first thing to do in this same file is to tell Phoenix that we are expecting uploads.
In the same form_component.ex
as before, we have to modify the socket :
defmodule App.PostLive.FormComponent do
#... the form hEex
@impl true
def update(%{post: post} = assigns, socket) do
changeset = Blog.change_post(post)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)
+ |> allow_upload(:feature_image, accept: ~w(.jpg .jpeg .png), max_entries: 1)
}
end
end
This does what it says on the tin and allows the upload of 1 entry of type .jpg, .jpeg or.png.
Now let's setup our event handlers. We need to handle the "saved" event for both creation and update. The out of the box "validate" can stay the same.
defmodule App.PostLive.FormComponent do
#... the form hEex
@impl true
def update(%{post: post} = assigns, socket) do
changeset = Blog.change_post(post)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)
|> allow_upload(:feature_image, accept: ~w(.jpg .jpeg .png), max_entries: 1)
}
end
#... The validate method
@impl true
def handle_event("saved", %{"post" => params}, %{:assigns => %{:uploads => %{:feature_image => image}}}=socket) when image.entries !== [] do
[uploaded_file] =
consume_uploaded_entries(socket, :feature_image, fn %{path: path}, entry ->
dest = Path.join(Application.app_dir(:ownidevapi, "priv/static/uploads"), "post_image" <> Path.extname(entry.client_name))
File.cp!(path, dest)
{:ok, dest}
end)
save_post(socket, socket.assigns.action, Map.put(params, "feature_image", uploaded_file))
end
def handle_event("saved", %{"post" => params}, socket) do
save_post(socket, socket.assigns.action, params)
end
# This should have been created for you by Phoenix gen
defp save_post(socket, :edit, post_params) do
case Blog.update_post(socket.assigns.post, post_params) do
{:ok, post} ->
notify_parent({:saved, post})
{:noreply,
socket
|> put_flash(:info, "Post updated successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
@impl true
defp save_post(socket, :new, post_params) do
case Blog.create_post(post_params) do
{:ok, post} ->
notify_parent({:saved, post})
{:noreply,
socket
|> put_flash(:info, "Post created successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end
Our "saved" events handle either a post with a featured_image or a post without (which will fail as it is required).
The first definition grabs the uploaded image and saves it under a new name to an upload folder. We then modify the value of "feature_image" in our post params to reflect the uploaded image.
Both of the handle_event/2
then pass the post params to the save_post/3
private function that will call a different method based on the current action (:new
post or :edit
post).
Finally, let's handle errors, always in the same file :
defmodule AppWeb.Blog.PostLive do
# The rest of your code...
defp error_to_string(:too_large), do: "Too large"
defp error_to_string(:too_many_files), do: "You have selected too many files"
defp error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
end
And this concludes the "feature image" part of the blog posts!
In the next post, we will scaffold API routes and functionalities.
See you then :)
- 1
Phoenix documentation on file uploads : https://hexdocs.pm/phoenix/1.7.10/file_uploads.html